﻿using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.Automation;
using System.Windows;
using System.Xml.Linq;

namespace FirstFloor.Documents.IO
{
    /// <summary>
    /// Provides conversion of Office documents.
    /// </summary>
    /// <remarks>
    /// Document conversion requires elevated permissions and Office (2007 or higher) needs to be installed on the local machine.
    /// </remarks>
    public class DocumentConverter
        : IDisposable
    {
        private const string ContentTypePresentation = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml";
        private const string ContentTypeSheet = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml";
        private const string ContentTypeDocument = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
        private const string ContentTypeFixedDocumentSequence = "application/vnd.ms-package.xps-fixeddocumentsequence+xml";

        // interop enums
        enum WdSaveFormat { wdFormatPDF = 0x11, wdFormatXPS = 0x12 }
        enum WdSaveOptions { wdDoNotSaveChanges = 0 }
        enum PpSaveAsFileType { ppSaveAsPDF = 0x20, ppSaveAsXPS = 0x21 }
        enum PbFixedFormatType { pbFixedFormatTypeXPS = 1 }
        enum XlFileFormat { xlXPS = 58 }
        enum PublishFormat { pfXPS = 4 }
        enum MsoTriState
        {
            msoCTrue = 1,
            msoFalse = 0,
            msoTriStateMixed = -2,
            msoTriStateToggle = -3,
            msoTrue = -1
        }

        private const short visOpenRO = 0x2, visOpenMinimized = 0x10, visOpenHidden = 0x40, visOpenMacrosDisabled = 0x80, visOpenNoWorkspace = 0x100;

        private const string ProgIdExcel = "Excel.Application";
        private const string ProgIdOneNote = "OneNote.Application";
        private const string ProgIdPowerPoint = "PowerPoint.Application";
        private const string ProgIdPublisher = "Publisher.Application";
        private const string ProgIdVisio = "Visio.Application";
        private const string ProgIdWord = "Word.Application";

        private static TempDiskStore tempStore;

        private Stream stream;
        private DocumentType? type;
        private bool disposed = false;

        /// <summary>
        /// Initializes a new instance of the <see cref="DocumentConverter"/> class.
        /// </summary>
        /// <param name="stream">The stream.</param>
        public DocumentConverter(Stream stream)
        {
            stream.VerifyReadAndSeek();
            this.stream = stream;
        }


        /// <summary>
        /// Releases unmanaged resources and performs other cleanup operations before the
        /// <see cref="DocumentConverter"/> is reclaimed by garbage collection.
        /// </summary>
        ~DocumentConverter()
        {
            Dispose(false);
        }

        private static TempDiskStore TempStore
        {
            get
            {
                if (tempStore == null) {
                    tempStore = new TempDiskStore();
                }
                return tempStore;
            }
        }

        /// <summary>
        /// Gets the type of the document.
        /// </summary>
        /// <value>The type of the document.</value>
        public DocumentType DocumentType
        {
            get
            {
                CheckDisposed();

                if (!type.HasValue) {
                    type = ParseDocumentType(this.stream);
                }
                return type.Value;
            }
        }

        /// <summary>
        /// Converts the document to XPS.
        /// </summary>
        /// <returns></returns>
        public Stream ConvertToXps()
        {
            CheckDisposed();

            var type = this.DocumentType;

            if (type == DocumentType.Xps) {
                // return copy of stream
                this.stream.Seek(0, SeekOrigin.Begin);
                return this.stream.ToMemoryStream();
            }

            if (!IsConversionEnabled) {
                throw new NotSupportedException("Document conversion requires elevated trust. Deploy as desktop application.");
            }

            stream.VerifyReadAndSeek();
            stream.Seek(0, SeekOrigin.Begin);

            string fileName;
            if (type == DocumentType.Word) {
                fileName = ConvertDocument(stream);
            }
            else if (type == DocumentType.PowerPoint) {
                fileName = ConvertPresentation(stream);
            }
            else if (type == DocumentType.Excel) {
                fileName = ConvertSheet(stream);
            }
            else if (type == DocumentType.Publisher) {
                fileName = ConvertPublication(stream);
            }
            // OneNote not supported due to bug in SL5 automation marshalling
            //else if (type == DocumentType.OneNote) {
            //    fileName = ConvertNote(stream);
            //}
            else if (type == DocumentType.Visio) {
                fileName = ConvertVisioDocument(stream);
            }
            else {
                throw new NotSupportedException(string.Format("Cannot convert stream of type {0} to XPS", type));
            }

            return File.OpenRead(fileName);
        }

        /// <summary>
        /// Determines whether conversion to XPS is enabled for the current application.
        /// </summary>
        /// <returns>
        /// 	<c>true</c> if conversion is enabled; otherwise, <c>false</c>.
        /// </returns>
        public static bool IsConversionEnabled
        {
            get
            {
                return Application.Current.HasElevatedPermissions;
            }
        }

        /// <summary>
        /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
        /// </summary>
        public void Dispose()
        {
            Dispose(true);

            GC.SuppressFinalize(this);
        }

        private void Dispose(bool disposing)
        {
            if (!this.disposed) {
                if (disposing) {
                    if (this.stream != null) {
                        this.stream.Dispose();
                    }
                }

                this.stream = null;
                this.disposed = true;
            }
        }

        private void CheckDisposed()
        {
            if (this.disposed) {
                throw new ObjectDisposedException("DocumentConverter");
            }
        }

        /// <summary>
        /// Parses the type of the document.
        /// </summary>
        /// <param name="stream">The stream.</param>
        /// <returns></returns>
        public static DocumentType ParseDocumentType(Stream stream)
        {
            // possible XPS or Office document in OPC format
            if (ZipFile.IsZipFile(stream)) {
                return ParseDocumentTypeFromZipStream(stream);
            }

            stream.Seek(0, SeekOrigin.Begin);

            // non OPC Office documents
            if (stream.IsMatch(0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1)) {
                // check sub-header
                stream.Seek(512, SeekOrigin.Begin);

                var subheader = stream.ReadBytes(8);

                if (IsMatch(subheader, 0xec, 0xa5, 0xc1, 0x00)) {
                    return DocumentType.Word;
                }
                if (IsMatch(subheader, 0xa0, 0x46, 0x1d, 0xf0)) {
                    return DocumentType.PowerPoint;
                }
                if (IsMatch(subheader, 0x00, 0x6e, 0x1e, 0xf0)) {
                    return DocumentType.PowerPoint;
                }
                if (IsMatch(subheader, 0x0f, 0x00, 0xe8, 0x03)) {
                    return DocumentType.PowerPoint;
                }
                if (IsMatch(subheader, 0x09, 0x08, 0x10, 0x00, 0x00, 0x06, 0x05, 0x00)) {
                    return DocumentType.Excel;
                }
                if (IsMatch(subheader, 0xfd, 0xff, 0xff, 0xff)) {
                    if (subheader[4] == 0xfe && subheader[5] == 0xff && subheader[6] == 0xff && subheader[7] == 0xff) {
                        return DocumentType.Visio;
                    }
                    if ((subheader[4] == 0x0e || subheader[4] == 0x1c || subheader[4] == 0x43 || subheader[4] >= 0xc0)) {
                        return DocumentType.PowerPoint;
                    }
                    if (subheader[4] == 0x20 && subheader[5] == 0x0) {
                        return DocumentType.Excel;
                    }
                    if (subheader[4] == 0x02 && subheader[5] == 0x0) {
                        return DocumentType.Publisher;
                    }
                    if (subheader[5] == 0x02) {
                        return DocumentType.Excel;
                    }
                }
            }

            return DocumentType.Unknown;
        }

        private static DocumentType ParseDocumentTypeFromZipStream(Stream stream)
        {
            stream.Seek(0, SeekOrigin.Begin);

            // try read open packaging ZIP
            var zipFile = new ZipFile(stream);
            // determine document type based on defined content types in packages
            var doc = LoadXDocument(zipFile, "[Content_Types].xml");

            if (doc == null) {
                return DocumentType.Unknown;
            }

            var contentTypes = (from e in doc.Descendants()
                                where e.Attribute("ContentType") != null
                                select (string)e.Attribute("ContentType")).Distinct().ToArray();

            if (contentTypes.Contains(ContentTypeFixedDocumentSequence)) {
                return DocumentType.Xps;
            }
            if (contentTypes.Contains(ContentTypeDocument)) {
                return DocumentType.Word;
            }
            if (contentTypes.Contains(ContentTypeSheet)) {
                return DocumentType.Excel;
            }
            if (contentTypes.Contains(ContentTypePresentation)) {
                return DocumentType.PowerPoint;
            }
            return DocumentType.Unknown;
        }

        private static XDocument LoadXDocument(ZipFile file, string fileName)
        {
            using (var stream = file.GetFileStream(new Uri(fileName, UriKind.Relative))) {
                if (stream != null) {
                    return XDocument.Load(stream);
                }
            }
            return null;
        }

        private static bool IsMatch(byte[] data, params int[] values)
        {
            for (int i = 0; i < values.Length; i++) {
                if (data[i] != values[i]) {
                    return false;
                }
            }
            return true;
        }

        private static dynamic CreateObject(string progId, string errorMessage)
        {
            try {
                return AutomationFactory.CreateObject(progId);
            }
            catch (Exception) {
                throw new InvalidOperationException(string.Format("Could not create instance of '{0}'. {1}", progId, errorMessage));
            }
        }

        private static string ConvertDocument(Stream stream)
        {
            using (var application = CreateObject(ProgIdWord, "Microsoft Word is required to read Word documents.")) {
                try {
                    // write to temp file
                    var fileName = TempStore.WriteToTempFile(stream);

                    // open document
                    var document = application.Documents.Open(fileName);

                    // convert to XPS
                    var xpsFileName = TempStore.GetNewTempFileName();
                    document.SaveAs(xpsFileName, (int)WdSaveFormat.wdFormatXPS);

                    return xpsFileName;
                }
                catch (Exception e) {
                    throw new InvalidOperationException("Document conversion failed", e);
                }
                finally {
                    try {
                        application.Quit((int)WdSaveOptions.wdDoNotSaveChanges);
                    }
                    catch (Exception) {
                        // too bad
                    }
                }
            }
        }

        private static string ConvertSheet(Stream stream)
        {
            using (var application = CreateObject(ProgIdExcel, "Microsoft Excel is required to read Excel sheets.")) {
                try {
                    // write to temp file
                    var fileName = TempStore.WriteToTempFile(stream);

                    // open document
                    var workbook = application.Workbooks.Open(fileName);

                    // convert to XPS
                    var xpsFileName = TempStore.GetNewTempFileName();
                    workbook.SaveAs(xpsFileName, (int)XlFileFormat.xlXPS);

                    workbook.Close(false);

                    // stupid-ass excel appends XPS extension
                    var outputFileName = xpsFileName + ".xps";
                    if (File.Exists(outputFileName)) {
                        // rename back to .tmp
                        File.Move(outputFileName, xpsFileName);
                    }

                    return xpsFileName;
                }
                catch (Exception e) {
                    throw new InvalidOperationException("Document conversion failed", e);
                }
                finally {
                    try {
                        application.Quit();
                    }
                    catch (Exception) {
                        // too bad
                    }
                }
            }
        }

        private static string ConvertPresentation(Stream stream)
        {
            using (var application = CreateObject(ProgIdPowerPoint, "Microsoft PowerPoint is required to read PowerPoint presentations.")) {
                try {
                    // write to temp file
                    var fileName = TempStore.WriteToTempFile(stream);

                    // open document
                    var document = application.Presentations.Open(fileName, (int)MsoTriState.msoTrue, (int)MsoTriState.msoFalse, (int)MsoTriState.msoFalse);        // open in hidden wnd

                    // convert to XPS
                    var xpsFileName = TempStore.GetNewTempFileName();
                    document.SaveAs(xpsFileName, (int)PpSaveAsFileType.ppSaveAsXPS);

                    return xpsFileName;
                }
                catch (Exception e) {
                    throw new InvalidOperationException("Document conversion failed", e);
                }
                finally {
                    try {
                        application.Quit();
                    }
                    catch (Exception) {
                        // too bad
                    }
                }
            }
        }

        private static string ConvertPublication(Stream stream)
        {
            using (var application = CreateObject(ProgIdPublisher, "Microsoft Publisher is required to read Publisher publications.")) {
                try {
                    // write to temp file
                    var fileName = TempStore.WriteToTempFile(stream);

                    // open document
                    var document = application.Open(fileName);

                    // convert to XPS
                    var xpsFileName = TempStore.GetNewTempFileName();
                    document.ExportAsFixedFormat((int)PbFixedFormatType.pbFixedFormatTypeXPS, xpsFileName);

                    return xpsFileName;
                }
                catch (Exception e) {
                    throw new InvalidOperationException("Document conversion failed", e);
                }
                finally {
                    try {
                        application.Quit();
                    }
                    catch (Exception) {
                        // too bad
                    }
                }
            }
        }

        private static string ConvertNote(Stream stream)
        {
            string notebookId = null;
            using (var application = CreateObject(ProgIdOneNote, "Microsoft OneNote is required to read OneNote documents.")) {
                try {
                    // write to temp file
                    var fileName = TempStore.WriteToTempFile(stream);

                    // open note


                    // SL4 BUG: out parameters not correctly marshalled (more details at 
                    // http://stackoverflow.com/questions/5813670/calling-onenote-gethierarchy-from-silverlight-application)
                    var document = application.OpenHierarchy(fileName, string.Empty, out notebookId, 0);

                    // convert to XPS
                    var xpsFileName = TempStore.GetNewTempFileName();
                    document.Publish(notebookId, xpsFileName, (int)PublishFormat.pfXPS);

                    return xpsFileName;
                }
                catch (Exception e) {
                    throw new InvalidOperationException("Document conversion failed", e);
                }
                finally {
                    try {
                        application.CloseNotebook(notebookId, true);
                    }
                    catch (Exception) {
                        // too bad
                    }
                }
            }
        }

        private static string ConvertVisioDocument(Stream stream)
        {
            using (var application = CreateObject(ProgIdVisio, "Microsoft Visio is required to read Visio documents.")) {
                try {
                    // write to temp file
                    var fileName = TempStore.WriteToTempFile(stream);

                    // open document
                    application.Visible = false;        // seems to be required?
                    var document = application.Documents.OpenEx(fileName, visOpenHidden + visOpenMacrosDisabled + visOpenMinimized + visOpenNoWorkspace + visOpenRO);        // open in hidden window
                    application.Visible = false;        // seems to be required?

                    // convert to XPS
                    var xpsFileName = TempStore.GetNewTempFileName();
                    document.ExportAsFixedFormat(2, xpsFileName, 1, 0);

                    return xpsFileName;
                }
                catch (Exception e) {
                    throw new InvalidOperationException("Document conversion failed", e);
                }
                finally {
                    try {
                        application.Quit();
                    }
                    catch (Exception) {
                        // too bad
                    }
                }
            }
        }
    }
}
